// Flashcards : file de révision (dues + nouvelles), création manuelle, génération IA. import { NextResponse } from "next/server"; import { z } from "zod"; import { apiError, parseBody, requireEnrollment } from "@/lib/api.ts"; import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts"; import { all, get, run } from "@/lib/db/index.ts"; import { normalizeCourse } from "@/lib/learning/helpers.ts"; import { getPrompt } from "@/lib/prompts.ts"; import { completeChat } from "@/lib/openrouter/client.ts"; import { resolvePreset, getModel, estimateCost } from "@/lib/openrouter/registry.ts"; import { hybridSearch, buildContext } from "@/lib/rag/search.ts"; import { checkBudget, logUsage } from "@/lib/usage.ts"; export async function GET(req: Request, ctx: { params: Promise<{ course: string }> }) { try { const user = await requireUser(); const course = normalizeCourse((await ctx.params).course); requireEnrollment(user.id, course); const url = new URL(req.url); const conceptSlug = url.searchParams.get("concept"); const conceptFilter = conceptSlug ? "AND f.concept_id = (SELECT id FROM concepts WHERE course_code = ? AND slug = ?)" : ""; const params: unknown[] = [user.id, course]; if (conceptSlug) params.push(course, conceptSlug); // Cartes dues (état existant) puis nouvelles (jamais vues), plafonnées. const due = all( `SELECT f.id, f.type, f.front, f.back, f.concept_id, c.name as concept_name, cs.ef, cs.interval_days, cs.reps, cs.due_at, cs.favorite FROM card_states cs JOIN flashcards f ON f.id = cs.card_id LEFT JOIN concepts c ON c.id = f.concept_id WHERE cs.user_id = ? AND f.course_code = ? ${conceptFilter} AND cs.suspended = 0 AND cs.due_at <= datetime('now') ORDER BY cs.due_at LIMIT 40`, ...params ); const fresh = all( `SELECT f.id, f.type, f.front, f.back, f.concept_id, c.name as concept_name FROM flashcards f LEFT JOIN concepts c ON c.id = f.concept_id WHERE f.course_code = ? ${conceptSlug ? "AND f.concept_id = (SELECT id FROM concepts WHERE course_code = ? AND slug = ?)" : ""} AND (f.owner_user_id IS NULL OR f.owner_user_id = ?) AND f.id NOT IN (SELECT card_id FROM card_states WHERE user_id = ?) ORDER BY f.id LIMIT 15`, ...(conceptSlug ? [course, course, conceptSlug, user.id, user.id] : [course, user.id, user.id]) ); const stats = { total: get<{ n: number }>( "SELECT COUNT(*) as n FROM flashcards WHERE course_code = ? AND (owner_user_id IS NULL OR owner_user_id = ?)", course, user.id )?.n ?? 0, learned: get<{ n: number }>( `SELECT COUNT(*) as n FROM card_states cs JOIN flashcards f ON f.id = cs.card_id WHERE cs.user_id = ? AND f.course_code = ? AND cs.reps >= 2`, user.id, course )?.n ?? 0, suspended: get<{ n: number }>( `SELECT COUNT(*) as n FROM card_states cs JOIN flashcards f ON f.id = cs.card_id WHERE cs.user_id = ? AND f.course_code = ? AND cs.suspended = 1`, user.id, course )?.n ?? 0, }; return NextResponse.json({ due, fresh, stats }); } catch (e) { return apiError(e); } } const createSchema = z.object({ action: z.literal("create"), conceptSlug: z.string().max(120).nullable(), type: z.enum(["qa", "definition", "formula", "error", "comparison", "calc"]).default("qa"), front: z.string().min(3).max(2000), back: z.string().min(1).max(4000), }); const generateSchema = z.object({ action: z.literal("generate"), conceptSlug: z.string().min(1).max(120), count: z.number().int().min(2).max(10).default(5), }); export async function POST(req: Request, ctx: { params: Promise<{ course: string }> }) { try { await assertSameOrigin(); const user = await requireUser(); const course = normalizeCourse((await ctx.params).course); requireEnrollment(user.id, course); const body = await parseBody(req, z.discriminatedUnion("action", [createSchema, generateSchema])); if (body.action === "create") { const conceptId = body.conceptSlug ? get<{ id: number }>("SELECT id FROM concepts WHERE course_code = ? AND slug = ?", course, body.conceptSlug)?.id ?? null : null; const r = run( `INSERT INTO flashcards (course_code, concept_id, type, front, back, created_by, owner_user_id, validated) VALUES (?, ?, ?, ?, ?, 'user', ?, 0)`, course, conceptId, body.type, body.front, body.back, user.id ); return NextResponse.json({ ok: true, cardId: Number(r.lastInsertRowid) }); } // Génération IA fondée sur le matériel du cours (RAG) — cartes privées à l'étudiant. const budget = checkBudget(user.id); if (!budget.ok) return NextResponse.json({ error: budget.reason }, { status: 429 }); const concept = get<{ id: number; name: string; description: string }>( "SELECT id, name, description FROM concepts WHERE course_code = ? AND slug = ?", course, body.conceptSlug ); if (!concept) return NextResponse.json({ error: "Concept inconnu." }, { status: 404 }); const results = await hybridSearch({ query: concept.name + " " + concept.description, spaces: [`official-${course.toLowerCase()}`], k: 6 }); const context = buildContext(results, 8000); const model = await resolvePreset("economique"); const t0 = Date.now(); const { text, promptTokens, completionTokens } = await completeChat({ model, messages: [ { role: "system", content: [getPrompt("base-system"), course === "IMM1003" ? getPrompt("course-imm1003") : getPrompt("course-imm1033"), getPrompt("flashcard-generation")].join("\n\n---\n\n") }, { role: "user", content: `Concept ciblé : « ${concept.name} » (slug: ${body.conceptSlug}). Produis ${body.count} cartes.\n\nMatériel du cours :\n${context.text}` }, ], jsonMode: true, temperature: 0.5, }); const mi = await getModel(model); logUsage({ userId: user.id, model, kind: "flashcard-gen", tokensIn: promptTokens, tokensOut: completionTokens, cost: mi ? estimateCost(mi, promptTokens, completionTokens) : 0, latencyMs: Date.now() - t0 }); let cards: { type?: string; front?: string; back?: string }[] = []; try { const parsed = JSON.parse(text.replace(/^```json?\s*|\s*```$/g, "")); cards = Array.isArray(parsed.flashcards) ? parsed.flashcards : []; } catch { return NextResponse.json({ error: "La génération a produit un format invalide. Réessayez." }, { status: 502 }); } const created: number[] = []; for (const c of cards.slice(0, body.count)) { if (!c.front || !c.back) continue; const r = run( `INSERT INTO flashcards (course_code, concept_id, type, front, back, created_by, owner_user_id, validated) VALUES (?, ?, ?, ?, ?, 'ai', ?, 0)`, course, concept.id, ["qa", "definition", "formula", "error", "comparison", "calc"].includes(c.type ?? "") ? c.type : "qa", String(c.front).slice(0, 2000), String(c.back).slice(0, 4000), user.id ); created.push(Number(r.lastInsertRowid)); } return NextResponse.json({ ok: true, created: created.length }); } catch (e) { return apiError(e); } }